Ce notebook contient le test technique pour le stage de data science chez Bouygues Telecom basée sur des données factices sur la résiliation des clients ayant un abonnement télécom.
L'objectif est de comprendre et de prédire la résilation des clients. Pour ce faire 4 datasets sont fournis :
Le notebook est composé de 3 parties et d'une conclusion :
Dans un premier temps, nous analyserons les données pour comprendre leur contenu et leur structure en utilisant des statistiques descriptives et des graphiques pour visualiser les distributions des données, ainsi que les problèmes éventuels, comme les données manquantes par exemple, qui devront être pris en compte lors de la modélisation.
pandas_profiling¶Pour ce faire, nous allons utiliser pandas_profiling qui permet d'explorer les données et de soulever certain problèmes de manière automatique. Puis nous allons regarder manuellement certaines corrélations. Cela va créer des profils avec des informations sur chaques variables (moyenne, médiane, valeurs manquantes, etc.), mais aussi sur quelques intéractions entre les variables (corrélations, etc.).
Ces profils sont ensuite exportés en format html dans le dossier nommé profiles.
Les données sont chargées à partir du dossier data qui se situe dans la racine du projet (comme ce notebook).
import pandas as pd
# Chargement des données situées dans le dossier `data/`
data_folder = "./data/"
client = pd.read_csv(data_folder + "resiliation_client.csv", sep=";")
contrat = pd.read_csv(data_folder + "resiliation_contrat.csv", sep=";")
forfait = pd.read_csv(data_folder + "resiliation_forfait.csv", sep=";")
option = pd.read_csv(data_folder + "resiliation_option.csv", sep=";")
import os
import warnings
warnings.filterwarnings("ignore")
from pandas_profiling import ProfileReport
# Création des profils automatiques
# Création du dossier `profils` qui contient les profils en html
os.makedirs("profils", exist_ok=True)
print("Pour la table client :")
profil_client = ProfileReport(client, title="Profil de la table client")
profil_client.to_file("profils/profil_client.html")
print("Pour la table contrat :")
profil_contrat = ProfileReport(contrat, title="Profil de la table contrat")
profil_contrat.to_file("profils/profil_contrat.html")
print("Pour la table option :")
profil_option = ProfileReport(option, title="Profil de la table option")
profil_option.to_file("profils/profil_option.html")
Pour la table client :
Summarize dataset: 100%|██████████| 22/22 [00:02<00:00, 9.19it/s, Completed] Generate report structure: 100%|██████████| 1/1 [00:00<00:00, 1.12it/s] Render HTML: 100%|██████████| 1/1 [00:00<00:00, 5.21it/s] Export report to file: 100%|██████████| 1/1 [00:00<00:00, 401.75it/s]
Pour la table contrat :
Summarize dataset: 100%|██████████| 27/27 [00:01<00:00, 22.82it/s, Completed] Generate report structure: 100%|██████████| 1/1 [00:01<00:00, 1.16s/it] Render HTML: 100%|██████████| 1/1 [00:00<00:00, 4.58it/s] Export report to file: 100%|██████████| 1/1 [00:00<?, ?it/s]
Pour la table option :
Summarize dataset: 100%|██████████| 25/25 [00:00<00:00, 28.04it/s, Completed] Generate report structure: 100%|██████████| 1/1 [00:00<00:00, 1.05it/s] Render HTML: 100%|██████████| 1/1 [00:00<00:00, 5.80it/s] Export report to file: 100%|██████████| 1/1 [00:00<00:00, 292.41it/s]
resiliation_client¶Nous commençons avec la table qui contient les données clients.
profil_client.to_notebook_iframe() # Affiche le profil dans une iframe
La table resiliation_client contient des données personnelles sur les clients, à savoir leur genre, leur situation familliale (en couple, parent) et une indication sur leur âge. Il y a également une information sur leur ancienneté chez Bouygues Telecom (en mois je suppose).
Le profil donne un certain nombre d'informations interessantes :
De plus, il peut être intéressant de noter la forte corrélation entre le fait d'être en couple et la situation parentale. En effet, le graphique suivant montre que, parmi les clients en couple, la moitié sont parents, alors que parmi les clients qui ne sont pas en couple, la grosse majorité n'est pas parent.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid")
%matplotlib inline
ax = sns.countplot(data=client.fillna({"parent": "NaN"}), x="couple", hue="parent")
ax.set(
xlabel="Couple",
ylabel="Nombre de clients",
title="Relation entre être en couple et être parent",
)
ax.legend(title="Parent")
plt.show()
De même, on voit clairement que les clients les plus anciens sont plus souvent en couple que les nouveaux clients. On peut donc penser que les clients célibataires sont plus enclins à résilier leur abonnement alors que les clients en couples le font moins souvent.
ax = sns.kdeplot(data=client, x="anciennete", hue="couple", cut=0, multiple="fill")
ax.set(
xlabel="Ancienneté",
ylabel="Pourcentage des clients",
title="Proportion des clients en couple selon l'ancienneté",
)
plt.show()
resiliation_forfait¶Pour la table avec les informations sur les forfaits, il n'est pas nécessaire de faire un profil. On peut simplement regarder les données car il n'y a que quelques lignes.
En effet, cette table contient uniquement la quantité de Go par mois pour les différents forfaits mobile.
forfait
| id_forfait | go_forfait | |
|---|---|---|
| 0 | Dkwg | 20 |
| 1 | Dktt | 50 |
| 2 | Dkop | 80 |
| 3 | Dkji | 120 |
| 4 | Dkgo | 200 |
resiliation_contrat¶Regardons ensuite la table resiliation_contrat qui contient les informations sur les contrats des clients.
profil_contrat.to_notebook_iframe() # Affiche le profil dans une iframe
La table resiliation_contrat contient les données sur les contrats souscrits par les clients, à savoir le forfait, la durée du contrat (mensuel, annuel, biannuel), la méthode de paiement, la facture mensuelle moyenne, la facture totale et surtout si le client a résilé son abonnement ou non.
Le profil donne un certain nombre d'informations interessantes :
Maintenant, regardons quelques interactions entre les variables. Nous d'abord ajouter l'ancienneté au dataset.
# On rajoute l'ancienneté à la table des contrats pour observer des corrélations
tmp1 = pd.merge(
client[["id_client", "anciennete"]],
contrat[
[
"id_client",
"id_forfait",
"facture_mensuelle_moyenne",
"contrat",
"resiliation",
]
],
on="id_client",
)
tmp1 = pd.merge(tmp1, forfait, on="id_forfait", how="left")
tmp1.head() # Table provisoire
| id_client | anciennete | id_forfait | facture_mensuelle_moyenne | contrat | resiliation | go_forfait | |
|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | 1 | NaN | 29.85 | Mensuel | Non | NaN |
| 1 | 5575-GNVDE | 34 | Dkop | 56.95 | un an | Non | 80.0 |
| 2 | 3668-QPYBK | 2 | Dkop | 53.85 | Mensuel | Oui | 80.0 |
| 3 | 7795-CFOCW | 45 | NaN | 42.30 | un an | Non | NaN |
| 4 | 9237-HQITU | 2 | Dkgo | 70.70 | Mensuel | Oui | 200.0 |
fig, (ax1, ax2, ax3, ax4) = plt.subplots(1, 4, figsize=(30, 5))
sns.kdeplot(
data=tmp1, x="anciennete", hue="resiliation", multiple="stack", ax=ax1, cut=0
)
ax1.set(
xlabel="Ancienneté",
ylabel="Pourcentage des clients qui résilient",
title="Résiliation selon l'ancienneté",
)
sns.boxplot(
data=tmp1.fillna({"go_forfait": "NaN"}),
x="go_forfait",
y="facture_mensuelle_moyenne",
ax=ax2,
hue="resiliation",
)
ax2.set(
xlabel="Forfait",
ylabel="Facture mensuelle moyenne",
title="Facture mensuelle moyenne par forfait",
)
sns.countplot(data=tmp1, x="contrat", hue="resiliation", ax=ax3)
ax3.set(
xlabel="Durée du contrat",
ylabel="Nombre de clients",
title="Résiliation selon la durée du contrat",
)
sns.countplot(
data=tmp1.fillna({"go_forfait": "NaN"}),
x="go_forfait",
hue="resiliation",
ax=ax4,
)
ax4.set(
xlabel="Forfait",
ylabel="Nombre de clients qui résilient",
title="Nombre de clients qui résilient selon le forfait",
)
plt.show()
Sur le graphique de gauche, on voit que l'ancienneté des clients a un effet très important sur la résiliation, en effet, les clients anciens ont moins tendance à résilier leur abonnement que les nouveaux clients.
Dans un deuxième temps, le graphique de droite montre que, quelque soit le forfait, les clients qui ont résilier avaient une facture mensuelle plus faible que les clients qui n'ont pas résilier. Cela peut être dû à des promotions à durée fixe, et donc, une fois cette durée terminée, les clients ont tendance à résilier leur abonnement. Ce qui est en accordance avec le premier graphique.
Ensuite, on peut voir que les données avec le forfait manquant sont très différents des autres en terme de facture mensuelle. En effet, les factures se situent entre le forfait à 20Go et celui à 80Go. On note clairement que la facture mensuelle est très corrélée à la quantité de Go. On peut donc supposer que les forfait manquants correspondent à celui qui n'est pas utilisé et qui contient 50Go. Cette hypothèse sera retenue lors du traitement des valeurs manquantes.
Sur le graphique suivant, on voit que les longs contrats sont moins susceptibles d'être résilier que les contrats courts.
Finalement, le dernier graphique montre que les gros abonnements à 200Go sont plus susceptibles de résilier que les autres abonnements.
resiliation_option¶profil_option.to_notebook_iframe() # Affiche le profil dans une iframe
La table resiliation_option contient les données sur les options ajoutées aux forfaits par les clients, notamment des options de téléphonie, de télévision, de sécurité, de support ou de stream.
Les seules valeurs manquantes sont dans les options de streaming, qui sont corrélées, en effet, si une des options est manquantes, l'autre l'ait également.
On remarque que toutes les options sont très corrélées avec la variable service_internet. En effet, si le service internet n'est pas inclu dans le forfait, aucune options ne peut l'être.
Cependant, ce ne sont pas des variables redondantes, car il y a tout de même des options choisies par certains clients.
On peut faire la même remarque pour l'option téléphonie et les lignes multiples.
fig, axs = plt.subplots(1, 4, figsize=(25, 5), sharey=True)
sns.countplot(data=option, x="service_internet", hue="option_securite", ax=axs[0])
axs[0].set(
xlabel="Service internet",
ylabel="Nombre de clients",
title="Service internet selon l'option de sécurité",
)
sns.countplot(data=option, x="service_internet", hue="stream_TV", ax=axs[1])
axs[1].set(
xlabel="Service internet",
ylabel="Nombre de clients",
title="Service internet selon l'option de stream TV",
)
sns.countplot(data=option, x="service_internet", hue="protection_terminal", ax=axs[2])
axs[2].set(
xlabel="Service internet",
ylabel="Nombre de clients",
title="Service internet selon l'option protection terminal",
)
sns.countplot(
data=option, x="option_service_telephone", hue="multiple_ligne", ax=axs[3]
)
axs[3].set(
xlabel="Option service téléphonique",
ylabel="Nombre de clients",
title="Option service téléphonique selon l'option de multiple ligne",
)
plt.show()
Sur le graphique suivant, on constate encore que les clients avec un forfait manquant sont uniques, en effet, aucun n'ont l'option de service telephonique. Cela renforce l'idée que les forfaits manquants sont dans une catégorie distincte, plutôt que des valeurs manquantes réparties entre les différents forfaits.
tmp2 = pd.merge(
contrat[["id_client", "id_forfait", "facture_mensuelle_moyenne"]],
option,
on="id_client",
)
ax = sns.countplot(
data=tmp2.fillna({"id_forfait": "NaN"}),
x="id_forfait",
hue="option_service_telephone",
)
ax.set(
xlabel="Forfait",
ylabel="Nombre de clients",
title="Nombre de clients par forfait en fonction de l'option de service",
)
plt.show()
Dans cette partie, nous allons travailler les données et les préparer pour qu'elles puissent être utilisées dans nos modèles.
Nous allons donc traiter le cas des valeurs manquantes et de la transformation des variables catégorielles en variables numériques.
Commençons par regrouper les données dans un dataframe nommé df en vérifiant que les id_client sont bien identiques.
df = pd.merge(
contrat,
client,
on="id_client",
how="outer",
validate="1:1",
indicator="client_contrat",
)
df = pd.merge(
df, option, on="id_client", how="outer", validate="1:1", indicator="client_option"
)
df = pd.merge(
df, forfait, on="id_forfait", how="left", validate="m:1", indicator="client_forfait"
)
df.head()
| id_client | id_forfait | contrat | facture_digitale | methode_paiement | facture_mensuelle_moyenne | facture_totale | resiliation | genre | senior | ... | service_internet | option_securite | option_backup | protection_terminal | support_technique | stream_TV | stream_Films | client_option | go_forfait | client_forfait | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | NaN | Mensuel | Oui | Cheque electronique | 29.85 | 29.85 | Non | Mademoiselle | Non | ... | DSL | Non | Oui | Non | Non | Non | Non | both | NaN | left_only |
| 1 | 5575-GNVDE | Dkop | un an | Non | Cheque par mail | 56.95 | 1889.50 | Non | Monsieur | Non | ... | DSL | Oui | Non | Oui | Non | Non | Non | both | 80.0 | both |
| 2 | 3668-QPYBK | Dkop | Mensuel | Oui | Cheque par mail | 53.85 | 108.15 | Oui | Monsieur | Non | ... | DSL | Oui | Oui | Non | Non | Non | Non | both | 80.0 | both |
| 3 | 7795-CFOCW | NaN | un an | Non | RIB Automatique | 42.30 | 1840.75 | Non | Monsieur | Non | ... | DSL | Oui | Non | Oui | Oui | Non | Non | both | NaN | left_only |
| 4 | 9237-HQITU | Dkgo | Mensuel | Oui | Cheque electronique | 70.70 | 151.65 | Oui | Madame | Non | ... | Fibre | Non | Non | Non | Non | Non | Non | both | 200.0 | both |
5 rows × 26 columns
df["client_contrat"].value_counts()
both 7043 left_only 0 right_only 0 Name: client_contrat, dtype: int64
df["client_option"].value_counts()
both 7043 left_only 0 right_only 0 Name: client_option, dtype: int64
df["client_forfait"].value_counts()
both 6361 left_only 682 right_only 0 Name: client_forfait, dtype: int64
On constate donc bien que les id_client sont des clés primaires et que les jointures sont bien en one to one.
Pour les forfaits, comme mentionné précedemment, on voit qu'il y en a qui sont manquants et qu'un forfait n'est pas du tout présent.
Générons le profil général, qui peut être interressant pour les corrélations.
profil_total = ProfileReport(df, title="Profil de la dataframe finale")
profil_total.to_file("profils/profil_total.html")
Summarize dataset: 100%|██████████| 50/50 [00:03<00:00, 15.77it/s, Completed] Generate report structure: 100%|██████████| 1/1 [00:02<00:00, 2.73s/it] Render HTML: 100%|██████████| 1/1 [00:00<00:00, 1.86it/s] Export report to file: 100%|██████████| 1/1 [00:00<00:00, 199.98it/s]
Pour commencer, nous allons évoquer les méthodes possibles pour traiter les différentes varibles.
df.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 7043 entries, 0 to 7042 Data columns (total 26 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id_client 7043 non-null object 1 id_forfait 6361 non-null object 2 contrat 7043 non-null object 3 facture_digitale 7043 non-null object 4 methode_paiement 7043 non-null object 5 facture_mensuelle_moyenne 7043 non-null float64 6 facture_totale 7032 non-null float64 7 resiliation 7043 non-null object 8 genre 6332 non-null object 9 senior 7043 non-null object 10 couple 7043 non-null object 11 parent 3449 non-null object 12 anciennete 7043 non-null int64 13 client_contrat 7043 non-null category 14 option_service_telephone 7043 non-null object 15 multiple_ligne 7043 non-null object 16 service_internet 7043 non-null object 17 option_securite 7043 non-null object 18 option_backup 7043 non-null object 19 protection_terminal 7043 non-null object 20 support_technique 7043 non-null object 21 stream_TV 5647 non-null object 22 stream_Films 5647 non-null object 23 client_option 7043 non-null category 24 go_forfait 6361 non-null float64 25 client_forfait 7043 non-null category dtypes: category(3), float64(3), int64(1), object(19) memory usage: 1.3+ MB
Nous allons donc regarder variables par variables ce qu'il est necéssaire de faire :
id_client est une clé primaire, donc elle ne contient pas d'information et il faut donc l'utiliser comme index.client_contrat, client_option et client_forfait ont été créées pour vérifier les clées primaires. Il faut donc les supprimer.id_forfait n'apporte pas plus d'information que la quantité de Go, mais on peut tout de même la garder pour les encoder. De plus, d'après les hypothèses précedentes, on peut supposer que le forfait manquant est celui qui n'est pas utilisé, et donc, mettre la valeur de 50Go.contrat apporte une information sur la durée du contrat, il semble donc que c'est une variable numérique. On la convertit donc en nombre de mois, quitte à la considérer comme une variable catégorielles par la suite.facture_totale a quelques valeurs manquantes, on peut les remplir en supposant que la facture totale vaut l'ancienneté fois le facture mensuelle moyenne. Cependant, la facture totale est manquante que quand l'ancienneté vaut 0. On peut donc remplacer par 0.facture_digitale, senior, couple, parent, et option_service_telephone sont des booléens, on peut donc les convertir en variables numériques avec 0 pour Non et 1 pour Oui.category_encoders, tel que TargetEncoder qui encode une catégorie en la moyenne de la variable target, ce qui "équivaut à la probabilité" de résiliation pour la catégorie. Ces estimateurs peuvent ensuite être utilisés sur les données de test en imputant les valeures apprises sur les données d'entrainement.On va tout d'abord séparer les données en deux groupes : les données d'entrainement et les données de test.
from sklearn.model_selection import train_test_split
# Dataframe finale
final_df = df.set_index("id_client").drop(
["client_contrat", "client_option", "client_forfait"], axis=1
)
# Split data et target
X, y = final_df.drop("resiliation", axis=1), final_df["resiliation"]
# Split data en train et test
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Sauvegarde les données générées et csv
os.makedirs("./data/train", exist_ok=True)
os.makedirs("./data/test", exist_ok=True)
X_train.to_csv("./data/train/X_train.csv")
y_train.to_csv("./data/train/y_train.csv")
X_test.to_csv("./data/test/X_test.csv")
y_test.to_csv("data/test/y_test.csv")
Maintenant, nous allons utiliser sklearn-pandas pour associer un ou plusieurs estimateurs pour chaque variable catégorielles, afin de les transformer en features utilisable par nos modèles.
from category_encoders import TargetEncoder
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer, KNNImputer, SimpleImputer
from sklearn.pipeline import FeatureUnion, Pipeline, make_pipeline
from sklearn.preprocessing import (
LabelEncoder,
MinMaxScaler,
OneHotEncoder,
OrdinalEncoder,
PowerTransformer,
QuantileTransformer,
StandardScaler,
)
from sklearn_pandas import DataFrameMapper
# Mapper qui permet de transformer les colonnes en colonnes numériques
# La stratégie de transformation est la plus simple pour le moment, pour les données manquantes,
# on impute avec la valeur la plus fréquente. Pour les données categoriales, on utilise du OneHotEncoding.
# Pour les données numériques, on utilise des transformations standards (StandardScaler, PowerTransformer, MinMaxScaler).
mapper = DataFrameMapper(
[
(
["id_forfait"],
[
SimpleImputer(strategy="constant", fill_value="Dktt"),
OneHotEncoder(handle_unknown="ignore"),
],
),
(["go_forfait"], SimpleImputer(strategy="constant", fill_value=50)),
(["contrat"], OneHotEncoder(handle_unknown="ignore")),
(["facture_digitale"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(["methode_paiement"], OneHotEncoder(handle_unknown="ignore")),
(["facture_mensuelle_moyenne"], MinMaxScaler()),
(
["facture_totale"],
[SimpleImputer(strategy="constant", fill_value=0), PowerTransformer()],
),
(
["genre"],
[
SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore"),
],
),
(["senior"], OneHotEncoder(handle_unknown="ignore")),
(["couple"], OneHotEncoder(handle_unknown="ignore")),
(
["parent"],
[
SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore", drop="first"),
],
),
(["anciennete"], MinMaxScaler()),
(["option_service_telephone"], OneHotEncoder(handle_unknown="ignore")),
(["multiple_ligne"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(["service_internet"], OneHotEncoder(handle_unknown="ignore")),
(["option_securite"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(["option_backup"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(["protection_terminal"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(["support_technique"], OneHotEncoder(handle_unknown="ignore", drop="first")),
(
["stream_TV"],
[
SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore", drop="first"),
],
),
(
["stream_Films"],
[
SimpleImputer(strategy="most_frequent"),
OneHotEncoder(handle_unknown="ignore", drop="first"),
],
),
],
df_out=True,
)
# On ajoute un column transformer pour supprimer les colonnes redondantes
ct = ColumnTransformer(
[
(
"drop_internet",
"drop",
make_column_selector(pattern="Pas de service internet"),
),
(
"drop_telephonique",
"drop",
make_column_selector(pattern="Pas de ligne telephonique"),
),
],
remainder="passthrough",
verbose_feature_names_out=False,
)
mapper1 = make_pipeline(mapper, ct)
Par exemple, si on applique ce mapper au dataset X_train, on obtient :
pd.DataFrame(
mapper1.fit_transform(X_train),
columns=ct.get_feature_names_out(),
index=X_train.index,
).head()
| id_forfait_x0_Dkgo | id_forfait_x0_Dkji | id_forfait_x0_Dkop | id_forfait_x0_Dktt | id_forfait_x0_Dkwg | go_forfait | contrat_x0_Mensuel | contrat_x0_deux ans | contrat_x0_un an | facture_digitale | ... | multiple_ligne_x0_Oui | service_internet_x0_DSL | service_internet_x0_Fibre | service_internet_x0_Non | option_securite_x0_Oui | option_backup_x0_Oui | protection_terminal_x0_Oui | support_technique_x0_Oui | stream_TV_x0_Oui | stream_Films_x0_Oui | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| id_client | |||||||||||||||||||||
| 4223-BKEOR | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 80.0 | 0.0 | 0.0 | 1.0 | 0.0 | ... | 0.0 | 1.0 | 0.0 | 0.0 | 1.0 | 0.0 | 1.0 | 0.0 | 0.0 | 1.0 |
| 6035-RIIOM | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | 0.0 | 1.0 | 0.0 | 1.0 | ... | 1.0 | 0.0 | 1.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 1.0 | 1.0 |
| 3797-VTIDR | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 50.0 | 1.0 | 0.0 | 0.0 | 1.0 | ... | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2568-BRGYX | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 200.0 | 1.0 | 0.0 | 0.0 | 1.0 | ... | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2775-SEFEE | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 80.0 | 0.0 | 1.0 | 0.0 | 1.0 | ... | 1.0 | 1.0 | 0.0 | 0.0 | 1.0 | 1.0 | 0.0 | 1.0 | 0.0 | 0.0 |
5 rows × 37 columns
Il est également possible de faire un autre mapper afin d'avoir un autre encoding.
# Ce mapper utilise du target encoding, il faut donc fit avec la target. De plus, TargetEncoder renvoie la moyenne s'il y a des données manquantes.
mapper2 = DataFrameMapper(
[
(
["id_forfait"],
[SimpleImputer(strategy="constant", fill_value="Dktt"), TargetEncoder()],
),
(["go_forfait"], SimpleImputer(strategy="constant", fill_value=50)),
(["contrat"], TargetEncoder()),
(["facture_digitale"], TargetEncoder()),
(["methode_paiement"], TargetEncoder()),
(["facture_mensuelle_moyenne"], None),
(["facture_totale"], SimpleImputer(strategy="constant", fill_value=0)),
(["genre"], TargetEncoder()),
(["senior"], TargetEncoder()),
(["couple"], TargetEncoder()),
(["parent"], TargetEncoder()),
(["anciennete"], None),
(["option_service_telephone"], TargetEncoder()),
(["multiple_ligne"], TargetEncoder()),
(["service_internet"], TargetEncoder()),
(["option_securite"], TargetEncoder()),
(["option_backup"], TargetEncoder()),
(["protection_terminal"], TargetEncoder()),
(["support_technique"], TargetEncoder()),
(["stream_TV"], TargetEncoder()),
(["stream_Films"], TargetEncoder()),
],
df_out=True,
)
Ce mapper renvoie une dataframe très différente, en effet, les colonnes ne sont pas dupliquées pour faire du OneHotEncoding, de plus la stratégie pour les valeurs manquantes est différente. Elle sont remplacées par la valeurs moyenne de la variable target.
mapper2.fit_transform(X_train, LabelEncoder().fit_transform(y_train))
| id_forfait | go_forfait | contrat | facture_digitale | methode_paiement | facture_mensuelle_moyenne | facture_totale | genre | senior | couple | ... | anciennete | option_service_telephone | multiple_ligne | service_internet | option_securite | option_backup | protection_terminal | support_technique | stream_TV | stream_Films | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| id_client | |||||||||||||||||||||
| 4223-BKEOR | 0.246677 | 80.0 | 0.117987 | 0.16414 | 0.190328 | 64.85 | 1336.80 | 0.267071 | 0.237098 | 0.326446 | ... | 21 | 0.266824 | 0.251397 | 0.191851 | 0.145342 | 0.398693 | 0.226825 | 0.413472 | 0.339155 | 0.306902 |
| 6035-RIIOM | 0.413724 | 200.0 | 0.028379 | 0.33594 | 0.174475 | 97.20 | 5129.45 | 0.267071 | 0.237098 | 0.326446 | ... | 54 | 0.266824 | 0.284105 | 0.415558 | 0.416014 | 0.216531 | 0.387706 | 0.413472 | 0.296443 | 0.306902 |
| 3797-VTIDR | 0.253623 | 50.0 | 0.426533 | 0.33594 | 0.449921 | 23.45 | 23.45 | 0.258701 | 0.237098 | 0.200733 | ... | 1 | 0.253623 | 0.253623 | 0.191851 | 0.416014 | 0.398693 | 0.387706 | 0.413472 | 0.268535 | 0.268535 |
| 2568-BRGYX | 0.413724 | 200.0 | 0.426533 | 0.33594 | 0.449921 | 70.20 | 237.95 | 0.280277 | 0.237098 | 0.326446 | ... | 4 | 0.266824 | 0.251397 | 0.415558 | 0.416014 | 0.398693 | 0.387706 | 0.413472 | 0.339155 | 0.328500 |
| 2775-SEFEE | 0.246677 | 80.0 | 0.028379 | 0.33594 | 0.174475 | 61.90 | 0.00 | 0.258701 | 0.237098 | 0.326446 | ... | 0 | 0.266824 | 0.284105 | 0.191851 | 0.145342 | 0.216531 | 0.387706 | 0.152855 | 0.339155 | 0.328500 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 0684-AOSIH | 0.413724 | 200.0 | 0.426533 | 0.33594 | 0.449921 | 95.00 | 95.00 | 0.258701 | 0.237098 | 0.200733 | ... | 1 | 0.266824 | 0.251397 | 0.415558 | 0.145342 | 0.398693 | 0.387706 | 0.413472 | 0.296443 | 0.306902 |
| 5982-PSMKW | 0.092990 | 120.0 | 0.028379 | 0.33594 | 0.152404 | 91.10 | 2198.30 | 0.267071 | 0.237098 | 0.200733 | ... | 23 | 0.266824 | 0.284105 | 0.191851 | 0.145342 | 0.216531 | 0.226825 | 0.152855 | 0.296443 | 0.306902 |
| 8044-BGWPI | 0.076606 | 20.0 | 0.426533 | 0.33594 | 0.449921 | 21.15 | 306.05 | 0.258701 | 0.237098 | 0.200733 | ... | 12 | 0.266824 | 0.251397 | 0.076606 | 0.076606 | 0.076606 | 0.076606 | 0.076606 | 0.073320 | 0.073320 |
| 7450-NWRTR | 0.413724 | 200.0 | 0.426533 | 0.33594 | 0.449921 | 99.45 | 1200.15 | 0.258701 | 0.413907 | 0.326446 | ... | 12 | 0.266824 | 0.284105 | 0.415558 | 0.416014 | 0.398693 | 0.226825 | 0.413472 | 0.268535 | 0.268535 |
| 4795-UXVCJ | 0.076606 | 20.0 | 0.117987 | 0.16414 | 0.152404 | 19.80 | 457.30 | 0.258701 | 0.237098 | 0.326446 | ... | 26 | 0.266824 | 0.251397 | 0.076606 | 0.076606 | 0.076606 | 0.076606 | 0.076606 | 0.268535 | 0.268535 |
5634 rows × 21 columns
Nous allons maintenant passer à la modélisation. Dans un premier temps, nous mettrons en place un modèle de base (régression logistique) pour avoir un premier score.
La métrique utilisée sera le recall. En effet, le recall semble important car il décrit le nombre de clients qui résilient, et qui n'ont pas été prédit comme tel par le modèle. Cela est donc utile si l'objectif est de conserver les clients le plus possible.
Par exemple, si on veut prévoir des clients à integrer dans une campagne de démarchage, ce modèle fournira la majorité des clients qui sont très suceptibles de résilier, en plus d'une quantité non négligeable de clients qui n'avait pas l'intention de résilier. Mais cela permet quand même de réduire drastiquement le scope de l'opération de démarchage.
Mais, il reste important de regarder la précision pour ne pas tout prédire comme résilient. Pour cela, on peut regarder le f1 score.
# On fit sur les données de train. Il est également possible de les rajouter dans des pipelines.
le = LabelEncoder().fit(y_train)
mapper1.fit(X_train, le.transform(y_train))
mapper2.fit(X_train, le.transform(y_train))
# X_train_processed = mapper1.transform(X_train)
# X_test_processed = mapper1.transform(X_test)
# y_train_processed = le.transform(y_train)
# y_test_processed = le.transform(y_test)
DataFrameMapper(df_out=True, drop_cols=[],
features=[(['id_forfait'],
[SimpleImputer(fill_value='Dktt',
strategy='constant'),
TargetEncoder(cols=[0])]),
(['go_forfait'],
SimpleImputer(fill_value=50, strategy='constant')),
(['contrat'], TargetEncoder(cols=[0])),
(['facture_digitale'], TargetEncoder(cols=[0])),
(['methode_paiement'], TargetEncoder(cols=[0])),
(['fa...
(['multiple_ligne'], TargetEncoder(cols=[0])),
(['service_internet'], TargetEncoder(cols=[0])),
(['option_securite'], TargetEncoder(cols=[0])),
(['option_backup'], TargetEncoder(cols=[0])),
(['protection_terminal'], TargetEncoder(cols=[0])),
(['support_technique'], TargetEncoder(cols=[0])),
(['stream_TV'], TargetEncoder(cols=[0])),
(['stream_Films'], TargetEncoder(cols=[0]))])
from sklearn.metrics import (
ConfusionMatrixDisplay,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
# Fonctions pour afficher la matrice de confusion
def plot_confusion_matrix(y_true, y_pred, label_encoder):
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5))
ConfusionMatrixDisplay.from_predictions(
y_true,
y_pred,
display_labels=label_encoder.classes_,
ax=ax1,
cmap="Blues",
normalize="true",
values_format=".0%",
)
ax1.grid(False)
ConfusionMatrixDisplay.from_predictions(
y_true, y_pred, display_labels=label_encoder.classes_, ax=ax2, cmap="Blues"
)
ax2.grid(False)
plt.show()
# Fonction pour afficher la matrice de confusion ainsi que certains scores
def plot_results(
y_true,
y_pred,
label_encoder,
metrics=[f1_score, roc_auc_score, precision_score, recall_score],
):
for metric in metrics:
print(metric.__name__, metric(y_true, y_pred))
plot_confusion_matrix(y_true, y_pred, label_encoder)
# Fonction qui entraine un modèle après appliquer un mapper et renvoie les scores et la matrice de confusion
def test_model(model, mapper, X_train, y_train, X_test, y_test, label_encoder):
pipe = make_pipeline(mapper, model)
pipe.fit(X_train, label_encoder.transform(y_train))
y_true, y_pred = label_encoder.transform(y_test), pipe.predict(X_test)
plot_results(y_true, y_pred, label_encoder)
return pipe
# Ajoute le sampling
def test_model_with_sampling(
model, mapper, sampler, X_train, y_train, X_test, y_test, label_encoder
):
y_train_processed = label_encoder.transform(y_train)
X_train_processed = mapper.fit_transform(X_train, y_train_processed)
X_test_processed = mapper.transform(X_test)
X_res, y_res = sampler.fit_resample(X_train_processed, y_train_processed)
clf = model.fit(X_res, y_res)
y_true, y_pred = label_encoder.transform(y_test), clf.predict(X_test_processed)
plot_results(y_true, y_pred, label_encoder)
return clf
Premièrement, nous allons utiliser un modèle de base, afin d'avoir une idée de la performance d'un modèle simple, en l'occurence, un modèle de régression logistique.
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression(random_state=42)
print("Pour le mapper 1")
log_reg_1 = test_model(clf, mapper1, X_train, y_train, X_test, y_test, le)
print("Pour le mapper 2")
log_reg_2 = test_model(clf, mapper2, X_train, y_train, X_test, y_test, le)
Pour le mapper 1 f1_score 0.6271676300578035 roc_auc_score 0.7416569192708604 precision_score 0.6802507836990596 recall_score 0.5817694369973191
Pour le mapper 2 f1_score 0.6235632183908046 roc_auc_score 0.7397264173403584 precision_score 0.6718266253869969 recall_score 0.5817694369973191
On voit donc que les deux preprocessing des données donnent des résultats similaires pour une regression logistique (le mapper 1 fonctionne un tout petit peu mieux que le mapper 2).
Le score f1 est à 0.63, ce qui est assez moyen, et surtout, le recall est très faible. Sur les 373 clients qui ont résilié dans le test set, il y a seulement 217 clients qui ont été prédit comme tel. Ce qui fait que l'on perdrait beacoup de clients.
from sklearn.ensemble import RandomForestClassifier
clf = RandomForestClassifier(random_state=42)
print("Pour le mapper 1")
rf_1 = test_model(clf, mapper1, X_train, y_train, X_test, y_test, le)
print("Pour le mapper 2")
rf_2 = test_model(clf, mapper2, X_train, y_train, X_test, y_test, le)
Pour le mapper 1 f1_score 0.5749613601236476 roc_auc_score 0.7068587162420942 precision_score 0.6788321167883211 recall_score 0.49865951742627346
Pour le mapper 2 f1_score 0.551829268292683 roc_auc_score 0.6933995466167049 precision_score 0.6395759717314488 recall_score 0.48525469168900803
Ce problème est encore pire si on regarde un RandomForest classique.
Il faut donc essayer de gérer le problème de déséquilibre des classes. Pour cela, on peut utiliser des technique de sampling qui enlève des données de la classes dominantes de manière aléatoire, ou rajoute des observations.
Pour ce faire, on va utiliser le package imblearn qui contient des stratégies de sampling.
from imblearn.combine import SMOTEENN
from imblearn.over_sampling import ADASYN, SMOTE, BorderlineSMOTE
from imblearn.under_sampling import TomekLinks
smote = SMOTE(random_state=42)
smoteen = SMOTEENN(random_state=42)
tomek = TomekLinks()
clf = LogisticRegression(random_state=42)
print("Pour le mapper 1 avec SMOTE")
lr_smote = test_model_with_sampling(
clf, mapper1, smote, X_train, y_train, X_test, y_test, le
)
print("Pour le mapper 1 avec SMOTEEN")
lr_smoteen = test_model_with_sampling(
clf, mapper1, smoteen, X_train, y_train, X_test, y_test, le
)
print("Pour le mapper 1 avec TomekLinks")
lr_smoteen = test_model_with_sampling(
clf, mapper1, tomek, X_train, y_train, X_test, y_test, le
)
Pour le mapper 1 avec SMOTE f1_score 0.6459227467811158 roc_auc_score 0.7789678801743144 precision_score 0.5384615384615384 recall_score 0.806970509383378
Pour le mapper 1 avec SMOTEEN f1_score 0.6165413533834587 roc_auc_score 0.7644852339892555 precision_score 0.47467438494934877 recall_score 0.8793565683646113
Pour le mapper 1 avec TomekLinks f1_score 0.6506666666666666 roc_auc_score 0.7628885588000869 precision_score 0.6472148541114059 recall_score 0.6541554959785523
On voit clairement, que cette technique permet d'améliorer de manière significative le recall, qui passe d'environ 50% à presque 90% avec le sampler SMOTEEN. Cepandant, la précision diminue significativement et le score f1 reste autour de 0.6.
Dans la suite, nous allons utiliser le sampler SMOTEEN.
Ici, nous allons de tester différents modèles de classification, pour voir s'il est possible d'améliorer le modèle de base.
from xgboost import XGBClassifier
clf = XGBClassifier(random_state=42)
print("Essai avec XGBClassifier et SMOOTEEN pour le mapper 1")
xgb_1 = test_model_with_sampling(
clf, mapper1, smoteen, X_train, y_train, X_test, y_test, le
)
print("Essai avec XGBClassifier et SMOOTEEN pour le mapper 2")
xgb_2 = test_model_with_sampling(
clf, mapper2, smoteen, X_train, y_train, X_test, y_test, le
)
Essai avec XGBClassifier et SMOOTEEN pour le mapper 1 [21:19:36] WARNING: C:/Users/Administrator/workspace/xgboost-win64_release_1.5.1/src/learner.cc:1115: Starting in XGBoost 1.3.0, the default evaluation metric used with the objective 'binary:logistic' was changed from 'error' to 'logloss'. Explicitly set eval_metric if you'd like to restore the old behavior. f1_score 0.6332288401253918 roc_auc_score 0.7705484592213814 precision_score 0.5188356164383562 recall_score 0.8123324396782842
Essai avec XGBClassifier et SMOOTEEN pour le mapper 2 [21:19:38] WARNING: C:/Users/Administrator/workspace/xgboost-win64_release_1.5.1/src/learner.cc:1115: Starting in XGBoost 1.3.0, the default evaluation metric used with the objective 'binary:logistic' was changed from 'error' to 'logloss'. Explicitly set eval_metric if you'd like to restore the old behavior. f1_score 0.6363636363636364 roc_auc_score 0.7636351403107435 precision_score 0.5628865979381443 recall_score 0.7319034852546917
import lightgbm as lgb
clf = lgb.LGBMClassifier(random_state=42)
print("Essai avec LGBMClassifier et SMOOTEEN pour le mapper 1")
lgb_1 = test_model_with_sampling(
clf, mapper1, smoteen, X_train, y_train, X_test, y_test, le
)
print("Essai avec LGBMClassifier et SMOOTEEN pour le mapper 2")
lgb_2 = test_model_with_sampling(
clf, mapper2, smoteen, X_train, y_train, X_test, y_test, le
)
Essai avec LGBMClassifier et SMOOTEEN pour le mapper 1 f1_score 0.6381647549530761 roc_auc_score 0.7750525324251866 precision_score 0.5221843003412969 recall_score 0.8203753351206434
Essai avec LGBMClassifier et SMOOTEEN pour le mapper 2 f1_score 0.6485207100591716 roc_auc_score 0.771732379641227 precision_score 0.5805084745762712 recall_score 0.7345844504021448
from sklearn.neural_network import MLPClassifier
clf = MLPClassifier(random_state=42)
print("Essai avec MLPClassifier et SMOOTEEN pour le mapper 1")
mlp_1 = test_model_with_sampling(
clf, mapper1, smoteen, X_train, y_train, X_test, y_test, le
)
print("Essai avec MLPClassifier et SMOOTEEN pour le mapper 2")
mlp_2 = test_model_with_sampling(
clf, mapper2, smoteen, X_train, y_train, X_test, y_test, le
)
Essai avec MLPClassifier et SMOOTEEN pour le mapper 1 f1_score 0.5981981981981982 roc_auc_score 0.7495768940138913 precision_score 0.45047489823609227 recall_score 0.8900804289544236
Essai avec MLPClassifier et SMOOTEEN pour le mapper 2 f1_score 0.6365638766519823 roc_auc_score 0.7686735950811018 precision_score 0.5401869158878505 recall_score 0.774798927613941
from sklearn.ensemble import AdaBoostClassifier
clf = AdaBoostClassifier(random_state=42)
print("Essai avec AdaBoostClassifier et SMOOTEEN pour le mapper 1")
ada_1 = test_model_with_sampling(
clf, mapper1, smoteen, X_train, y_train, X_test, y_test, le
)
print("Essai avec AdaBoostClassifier et SMOOTEEN pour le mapper 2")
ada_2 = test_model_with_sampling(
clf, mapper2, smoteen, X_train, y_train, X_test, y_test, le
)
Essai avec AdaBoostClassifier et SMOOTEEN pour le mapper 1 f1_score 0.6333012512030799 roc_auc_score 0.7783739791112445 precision_score 0.493993993993994 recall_score 0.8820375335120644
Essai avec AdaBoostClassifier et SMOOTEEN pour le mapper 2 f1_score 0.6613816534541336 roc_auc_score 0.786208556315795 precision_score 0.5725490196078431 recall_score 0.7828418230563002
Ainsi, l'estimateur AdaBoost, la regression logistique et le réseau de neurones semblent les plus performant en terme de recall, cependant, AdaBoost a un meilleur score f1 que les deux autres. Nous allons donc utiliser celui-ci, avec le mapper 1 et le sampler SMOTEEN.
Maintenant, nous allons voir s'il est possible d'améliorer le modèle en optimisant les hyperparamètres.
from sklearn.model_selection import GridSearchCV
gscv = GridSearchCV(
estimator=AdaBoostClassifier(random_state=42),
param_grid={
"n_estimators": [50, 100, 200, 400],
"learning_rate": [0.1, 0.5, 1.0],
},
scoring="recall",
cv=5,
n_jobs=-1,
)
y_train_processed = le.transform(y_train)
X_train_processed = mapper1.fit_transform(X_train, y_train_processed)
X_test_processed = mapper1.transform(X_test)
X_res, y_res = smoteen.fit_resample(X_train_processed, y_train_processed)
gscv.fit(X_res, y_res)
GridSearchCV(cv=5, estimator=AdaBoostClassifier(random_state=42), n_jobs=-1,
param_grid={'learning_rate': [0.1, 0.5, 1.0],
'n_estimators': [50, 100, 200, 400]},
scoring='recall')
gscv.best_estimator_.get_params()
{'algorithm': 'SAMME.R',
'base_estimator': None,
'learning_rate': 0.5,
'n_estimators': 400,
'random_state': 42}
On voit que les meilleurs paramètres sont différents des valeurs par défaut, qui sont 50 estimateurs et 1.0 pour le paramètre learning_rate.
# Matrice de confusion pour le modèle issu de la GridSearch
print("Score du modèle AdaboostClassifier sélectionné avec GridSearch")
y_true, y_pred = le.transform(y_test), gscv.predict(X_test_processed)
plot_results(y_true, y_pred, le)
Score du modèle AdaboostClassifier sélectionné avec GridSearch f1_score 0.6399217221135028 roc_auc_score 0.7829323962031737 precision_score 0.5038520801232665 recall_score 0.8766756032171582
# Modèle par défaut qui a été sélectionné plus haut.
print("Score du modèle AdaboostClassifier précédemment sélectionné")
test_model_with_sampling(
AdaBoostClassifier(
**{
"algorithm": "SAMME.R",
"base_estimator": None,
"learning_rate": 1.0,
"n_estimators": 50,
}
),
mapper1,
smoteen,
X_train,
y_train,
X_test,
y_test,
le,
);
Score du modèle AdaboostClassifier précédemment sélectionné f1_score 0.6333012512030799 roc_auc_score 0.7783739791112445 precision_score 0.493993993993994 recall_score 0.8820375335120644
Cependant, quand on compare les deux matrices de confusion, on voit que le premier modèle est légerement meilleur que le second. Par contre, le modèle issu de la GridSearch a un meilleur score f1. Il reste à voir si la performance ajoutée avec ce modèle est suffisante pour compenser la complexité fortement accrue. En effet, le modèle de classification le plus simple fonctionnait déjà très bien, il faut voir si on peut l'optimiser un peu et s'il devient meilleur.
print("Score de la regression logistique")
lr_smoteen = test_model_with_sampling(
LogisticRegression(random_state=42),
mapper1,
smoteen,
X_train,
y_train,
X_test,
y_test,
le,
)
Score de la regression logistique f1_score 0.6165413533834587 roc_auc_score 0.7644852339892555 precision_score 0.47467438494934877 recall_score 0.8793565683646113
gscv2 = GridSearchCV(
estimator=LogisticRegression(random_state=42, n_jobs=-1),
param_grid={
"C": [0.1, 0.5, 1.0, 5.0],
"max_iter": [100, 200, 500],
"penalty": ["l1", "l2", "none"],
"fit_intercept": [True, False],
},
scoring=["recall", "f1"],
refit="f1",
cv=5,
n_jobs=-1,
)
gscv2.fit(X_res, y_res)
gscv2.best_estimator_.get_params()
{'C': 0.1,
'class_weight': None,
'dual': False,
'fit_intercept': False,
'intercept_scaling': 1,
'l1_ratio': None,
'max_iter': 500,
'multi_class': 'auto',
'n_jobs': -1,
'penalty': 'none',
'random_state': 42,
'solver': 'lbfgs',
'tol': 0.0001,
'verbose': 0,
'warm_start': False}
# Matrice de confusion pour le modèle de regression logistique issu de la GridSearch
print("Score de la regression logistique sélectionnée avec GridSearch")
plot_results(le.transform(y_test), gscv2.predict(X_test_processed), le)
Score de la regression logistique sélectionnée avec GridSearch f1_score 0.614100185528757 roc_auc_score 0.7631978014015547 precision_score 0.4695035460992908 recall_score 0.8873994638069705
# On sauvegarde le modèle sélectionné
from joblib import dump
os.makedirs("models", exist_ok=True)
dump(gscv2.best_estimator_, 'models/log_reg_smoteen.joblib')
['models/log_reg_smoteen.joblib']
Au final, on vient bien que la régression linéaire est très performante bien que c'est un modèle très simple. Je vais donc choisir ce modèle car il sera plus simple à expliquer et à interpréter.
Dans cette partie, nous allons nous pencher plus en détails sur les variables qui ont un impact sur les décisions du modèle. Pour ce faire, nous allons utiliser le package explainerdashboard et shap.
from explainerdashboard import ClassifierExplainer, ExplainerDashboard
cats = [
{
"id_forfait": [
"id_forfait_x0_Dkgo",
"id_forfait_x0_Dkji",
"id_forfait_x0_Dkop",
"id_forfait_x0_Dktt",
"id_forfait_x0_Dkwg",
]
},
{"contrat": ["contrat_x0_Mensuel", "contrat_x0_deux ans", "contrat_x0_un an"]},
{
"methode_paiement": [
"methode_paiement_x0_Carte de credit Automatique",
"methode_paiement_x0_Cheque electronique",
"methode_paiement_x0_Cheque par mail",
"methode_paiement_x0_RIB Automatique",
]
},
{"genre": ["genre_x0_Madame", "genre_x0_Mademoiselle", "genre_x0_Monsieur"]},
{"senior": ["senior_x0_Non", "senior_x0_Oui"]},
{"couple": ["couple_x0_Non", "couple_x0_Oui"]},
{
"option_service_telephonique": [
"option_service_telephone_x0_Non",
"option_service_telephone_x0_Oui",
]
},
{
"service_internet": [
"service_internet_x0_DSL",
"service_internet_x0_Fibre",
"service_internet_x0_Non",
]
},
]
tmp = pd.DataFrame(
X_test_processed, columns=ct.get_feature_names_out(), index=y_test.index
)
explainer = ClassifierExplainer(
gscv2.best_estimator_,
tmp,
le.transform(y_test),
cats=cats,
labels=le.classes_.tolist(),
idxs=y_test.index,
index_name="Id Client",
target="Résiliation",
n_jobs=-1,
)
db = ExplainerDashboard(explainer, title="Resiliation Model Explainer")
WARNING: For shap='linear', shap interaction values can unfortunately not be calculated!
Note: model_output='probability' is currently not supported for linear classifiers models with shap. So defaulting to model_output='logodds' If you really need probability outputs use shap='kernel' instead.
Note: shap values for shap='linear' get calculated against X_background, but paramater X_background=None, so using X instead...
Generating self.shap_explainer = shap.LinearExplainer(model, X)...
Building ExplainerDashboard..
Detected notebook environment, consider setting mode='external', mode='inline' or mode='jupyterlab' to keep the notebook interactive while the dashboard is running...
For this type of model and model_output interactions don't work, so setting shap_interaction=False...
The explainer object has no decision_trees property. so setting decision_trees=False...
Generating layout...
Calculating shap values...
Calculating prediction probabilities...
Calculating metrics...
Calculating confusion matrices...
Calculating classification_dfs...
Calculating roc auc curves...
Calculating pr auc curves...
Calculating liftcurve_dfs...
Calculating dependencies...
Calculating permutation importances (if slow, try setting n_jobs parameter)...
Calculating predictions...
Calculating pred_percentiles...
Reminder: you can store the explainer (including calculated dependencies) with explainer.dump('explainer.joblib') and reload with e.g. ClassifierExplainer.from_file('explainer.joblib')
Registering callbacks...
db.run(mode="inline")
Warning: Original ExplainerDashboard was initialized with mode='dash'. Rebuilding dashboard before launch:
Building ExplainerDashboard..
For this type of model and model_output interactions don't work, so setting shap_interaction=False...
The explainer object has no decision_trees property. so setting decision_trees=False...
Generating layout...
Calculating dependencies...
Reminder: you can store the explainer (including calculated dependencies) with explainer.dump('explainer.joblib') and reload with e.g. ClassifierExplainer.from_file('explainer.joblib')
Registering callbacks...
Starting ExplainerDashboard inline (terminate it with ExplainerDashboard.terminate(8050))
Ce dashboard interactif permet les shap values et donc l'importance des différentes variables sur le modèle.
Dans ce cas, on voit que les factures et l'ancienneté sont les variables les plus importantes pour décider si un client est résilier ou non. Il faut quand même noter que ces variables sont très corrélées, et donc interchangeables, car la facture totale est presque égale au produit de la facture mensuelle moyenne et de l'ancienneté.
Sinon, on voit que la durée du contrat est également très importante.
D'un autre côté, les variables genre, senior et couple ont un impact très faibles sur le modèle, ce qui peut être étonnant, surtout pour la situation conjuguale.
Ce dashboard permet également de voir les shap values pour les clients individuellement et ainsi de comprendre comment l'algorithme prend la décision. On peut aussi, changer la valeur d'un paramètre d'un client pour voir si la décision change.
Le graphique Precision Plot dans l'onglet Classification Stats montre que les décisions sont très souvent bien claires et que la majorité des clients qui résilient sont bien classés. Pour encore améliorer le score, on aurait pu baisser le threshold en dessous de 0.5.
On y voit aussi les courbes de trade-off entre la précision et le recall. Et on voit très clairement que le recall est privilégié par rapport à la précision. Ce que se traduit par une assez mauvaise classification des clients ne souhaitant pas résilier. Cependant, ce n'était pas mon objectif.
Pour comprendre l'impact positif ou négatif des variables, le package shap fournit le graphique Beeswarm qui illustre bien cela.
import shap
shap.initjs()
explainer = shap.Explainer(gscv2.best_estimator_, tmp)
shap_values = explainer(tmp)
shap.plots.beeswarm(shap_values, order=shap_values.abs.max(0), max_display=20)
Ce graphique montre qu'une facture totale élévée a un impact négatif sur la décision du modèle, à savoir que le client va résilier. Ceci s'explique par le fait que les clients avec une facture totale élevée sont anciens et ont moins de chance de résilier.
Au contraire, une facture mensuelle faible se traduit par plus de résiliation, il y a probablement plusieurs facteurs pouvant expliquer cela, on peut supposer que le fait que les clients avec un "petit" forfait ont plus tendance à en changer.
Le type de contrat à aussi un impact, par exemple, les longs contrats (de 2 ans) sont préférables à ceux de moins de 1 mois.
Ainsi, on a construit un modèle de classification dont l'objectif principal est de déterminer si un client va résilier, dans l'optique d'essayer de le conserver en lui proposant des offres ou en faisant un démarchage plus ciblé. Pour cette tâche l'algorithme utilisé est performant, bien qu'il soit très simple. Il est donc facile d'expliquer les décisions avec les shap values notamment.
Je vais finir par proposer des pistes d'améliorations ou de développements plus complexes.
optuna à la place de GridSearch CV par exemple.Repo GitHub du projet: https://github.com/gwatkinson/test-technique-bouygues